test(stress): gate large-catchup on a throughput floor and emit host contention counters - #617
Conversation
…counters The nightly Large-Data Stress Tests job failed on 2026-07-25 and 2026-07-26 at the identical harper-pro head SHA and core pointer that passed on 2026-07-24, both times as a wall-clock miss of the 1800s catch-up budget at ~90% converged. Runner-side evidence (sar history on the harper-bench host) shows the box was not contended on either failing night — loadavg, %iowait, %steal, PSI and nvme %util were all at or below the passing night's, and the box had ~700 fewer processes on 7/26 after a reboot. A's write phase also held 251/194 MB/s on the failing nights against 269 MB/s on the passing one, so the slowdown was specific to catch-up, not host-wide. What is actually happening is that catch-up throughput is bimodal on identical code: 13.5 / 11.4 / 10.0 MB/s when the replay accelerates partway through, and ~5.1 MB/s when it plods for the whole run. A fixed wall-clock budget cannot separate "slow but healthy" from "regressed" against that spread; it just fails whichever mode lands past the line. So the hard gate becomes a throughput floor rather than a clock. The deadline is derived as the time CATCHUP_FLOOR_MBPS (2.5 MB/s, half the slowest healthy run measured) needs for the dataset, plus a restart grace, so missing it means and reads as a throughput regression. CATCHUP_BASELINE_MBPS (10 MB/s) is the tracked number: falling under it emits an Actions warning annotation and is recorded, but does not fail the job. Every other hard failure is unchanged — the wedge no-progress guard, over-replication, OOM, uncaughtException, RSS and cgroup-anon caps, and A exiting mid-write. Host pressure is now sampled from /proc (not namespaced by Docker, so it reads the whole box from inside the job container): loadavg, CPU iowait/steal, PSI cpu/io/memory stall shares, whole-disk read/write MB/s and MemAvailable. It is printed on every catch-up poll line, summarised for the write phase and the catch-up phase separately, included in the failure message, and written to large-catchup-metrics.json in the uploaded log artifact plus the job summary — so a future slow run is attributable from the artifact alone. Co-Authored-By: Claude Opus <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces host-level pressure monitoring (CPU, disk I/O, and PSI metrics from /proc) to the large catch-up stress test, transitioning the test budget from a fixed wall-clock limit to an enforced throughput floor. It also adds functionality to persist run metrics as JSON and write markdown summaries to GitHub Actions. The review feedback highlights a potential TypeError due to missing optional chaining on a regex match, a sample pollution issue in window() that could skew peak rate calculations, and a recommendation to ignore intervals shorter than one second to prevent rate calculation spikes.
|
Reviewed; no blockers found. |
… harden metrics Applies review findings from PR #617: - largeCatchup: the grace-adjusted deadline let a converged run post a measured rate below ENFORCED_FLOOR_MBPS and still pass, since the gate checked convergedAt !== null rather than the achieved rate itself. Gate on catchupMBps >= ENFORCED_FLOOR_MBPS directly, which also covers an outright timeout without a separate check. - largeCatchup: the exact_count convergence poll forces a full value scan of B's table every 5s, and that scan grows with B's row count and competes with the replication replay it's trying to measure. Widen the poll interval (HARPER_STRESS_LARGE_CATCHUP_POLL_SECS, default 15s) to reduce how often the benchmark scans itself. - stressShared: writeStressMetrics's fixed path can be read as fresh data after a crash that happens before it's called, on a self-hosted runner that reuses its workspace across runs. Add clearStressMetrics(), called at suite start, so an early failure uploads nothing rather than a stale prior run. - stressShared: sampleHostCounters().window() pushed its on-demand reading into the same samples array the timer fills on a fixed cadence; two entries landing milliseconds apart produce a manufactured peak rate when summariseHostSamples() walks them pairwise. Stop pushing from window(). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Reviewed — |
?.[1] short-circuits to undefined when /proc/stat's cpu line is missing, but the following .trim() ran unconditionally and threw before the ?? [] fallback could apply — breaking readHostCounters' "return null for any source the platform lacks" contract and surfacing as an uncaught exception in the setInterval sampling callback. Addresses PR #617 review threads from gemini-code-assist and claude. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Reviewed — |
… harden metrics Applies review findings from PR #617: - largeCatchup: the grace-adjusted deadline let a converged run post a measured rate below ENFORCED_FLOOR_MBPS and still pass, since the gate checked convergedAt !== null rather than the achieved rate itself. Gate on catchupMBps >= ENFORCED_FLOOR_MBPS directly, which also covers an outright timeout without a separate check. - largeCatchup: the exact_count convergence poll forces a full value scan of B's table every 5s, and that scan grows with B's row count and competes with the replication replay it's trying to measure. Widen the poll interval (HARPER_STRESS_LARGE_CATCHUP_POLL_SECS, default 15s) to reduce how often the benchmark scans itself. - stressShared: writeStressMetrics's fixed path can be read as fresh data after a crash that happens before it's called, on a self-hosted runner that reuses its workspace across runs. Add clearStressMetrics(), called at suite start, so an early failure uploads nothing rather than a stale prior run. - stressShared: sampleHostCounters().window() pushed its on-demand reading into the same samples array the timer fills on a fixed cadence; two entries landing milliseconds apart produce a manufactured peak rate when summariseHostSamples() walks them pairwise. Stop pushing from window(). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
What
Replaces the fixed wall-clock catch-up budget in
integrationTests/stress/largeCatchup.test.mjswith a throughput floor, and adds host-pressure counters to the run log, the job summary, and the uploaded log artifact.Why — verdict on host contention: refuted, with runner-side evidence
The nightly
Large-Data Stress Tests/large-catchupjob failed on 2026-07-25 and 2026-07-26 at the identical harper-pro head SHA (86f2955e) and identical pinned core pointer (cda8d63f) that passed on 2026-07-24. Both failures were a wall-clock miss of the 1800s budget at ~87–90% converged, not a wedge.The working hypothesis going in was host-level I/O and CPU contention on the shared
harper-benchbox. The runner-side evidence does not support it.sarhistory on that host (/var/log/sysstat/sa24,sa25,sa26), aligned to each job'sstartedAt/completedAt:Every contention indicator is at or below the passing night's on both failing nights. 07-26 in particular: the box had been rebooted the previous evening, was running ~700 fewer processes with half the committed memory, and the disk was at half the passing night's utilisation — and the run still failed.
Two further discriminators point the same way:
What is actually happening
Catch-up throughput is bimodal on identical code. Reconstructing the per-poll progress from the job logs:
Measured 10 GB distribution: 13.5, 11.4, 10.0 MB/s (converged) and ~5.1, ~5.2 MB/s (slow mode). The old 1800s budget sits at 5.7 MB/s equivalent — right in the middle of that distribution, which is why the job coin-flips.
I have not root-caused the fast/slow mode split; that is filed as a follow-up (see Findings in the dispatch). This PR makes the signal trustworthy, it does not fix the throughput variance.
The new criterion
The deadline is no longer a chosen constant — it is the time the floor rate needs for this dataset, plus a grace for B's restart/reconnect. Missing it therefore means, and now reads as, "average catch-up throughput was below the floor". At 10 GB that is 4216s (~70 min), inside the job's existing
timeout-minutes: 180; a healthy run still exits as soon as it converges (900–2100s observed), so typical nightly wall-clock is unchanged.Floor = 2.5 MB/s, chosen as half the slowest healthy run measured (5.1 MB/s), not a round number.
What would still catch a genuine 2× replication regression: a 2× slowdown from the slow mode (5.1 → 2.55 MB/s) trips the floor and hard-fails. A 2× slowdown from the fast mode (11.4 → 5.7 MB/s) does not — and honestly cannot, because that is indistinguishable from the slow mode this codebase already produces. That case is covered by the tracked metric instead: any run under
CATCHUP_BASELINE_MBPS(10 MB/s) emits a::warningannotation on the run page and records the number in the metrics artifact, so a sustained downward trend is visible before it ever reaches the floor. Closing that gap properly requires eliminating the bimodality, not a tighter threshold.Unchanged hard failures: the
STALL_SECS(300s) wedge / no-progress guard and its wording from 806a829, over-replication (lastCount > targetCount), OOM anduncaughtExceptionlog scans, theRSS_CAP_MBandANON_CAP_MBmemory ceilings, and the A-process-exit watcher during the write phase.Host-side observability
stressShared.mjsgainsreadHostCounters()/hostCounterDelta()/sampleHostCounters()/summariseHostSamples()/formatHostCounters()./proc/stat,/proc/diskstats,/proc/loadavg,/proc/pressure/*and/proc/meminfoare not namespaced by Docker, so the job container reads the whole box through them — exactly the signal the existing in-cgroup stats cannot see (they were identical on pass and fail nights, which is why attribution was impossible from the artifacts).Emitted as: a suffix on every catch-up poll line; a
write host:line and ahost (catch-up):+host peaks:pair; inside the assertion failure message itself; and aslarge-catchup-metrics.jsoninHARPER_INTEGRATION_TEST_LOG_DIR(i.e. thelarge-stress-logs-large-catchup-node-24artifact) plus aGITHUB_STEP_SUMMARYtable.Test notes
Verified locally at reduced scale per the test's docstring (
HARPER_RUN_STRESS_TESTS=1 HARPER_STRESS_LARGE_DATA_GB=1), on the harper-bench host itself:large-catchup-metrics.jsonand the job-summary table written.pass 1 / fail 0.HARPER_STRESS_LARGE_CATCHUP_BUDGET_SECS=2; fails withB averaged 0.0 MB/s, under the 1024.0 MB/s catch-up floor (0/10486 records in 2s) — slow replay, not a wedge; a wedge would have tripped the 300s no-progress guard. Host during catch-up: load=1.07 iowait=2% ….HARPER_STRESS_LARGE_CATCHUP_BASELINE_MBPS=1000; emits the::warningannotation and still passes.The floor quoted in messages is
ENFORCED_FLOOR_MBPS, derived back out of the actual budget, so it stays truthful when the 600s minimum or an explicit budget override dominates (hence1024.0 MB/sin the forced case above — that is the arithmetic consequence of a 2s budget, not a config value).A full 10 GB run was not attempted locally; the wedge guard, over-replication guard and memory caps are untouched code paths.
Not touched
Per the brief: the
coresubmodule pointer is unchanged, Sync Core PR #610 was neither merged nor approved, andlargeCloneplus the other stress matrix entries are untouched.stressShared.mjschanges are additive — existing exports are unmodified, so the other stress tests are unaffected.Related context (not addressed here — Kris's merge call)
harper-pro
main's core pointer (cda8d63f, 2026-07-22) is 23 commits behind harpermainand is missing both replication resume-cursor fixes —9aca3264c"fix(replication): stop the resume-cursor write freezing the apply worker" ande47f9ca68"fix(replication): abort the cursor transaction when its commit fails" — which closed #603, whose signature was the apply worker freezing up to 101s under RocksDB write stall. That is the same family as the 72–90s inter-batch stalls still visible in these runs, so the nightly is currently exercising a more fragile Harper than post-fix Harper actually is. Sync Core #610 would land it. It cannot explain the 07-24 → 07-25 change (07-24 passed on the same fix-less core), so it is context, not cause.Generated by Claude Opus 5.
🤖 Generated with Claude Code